#coding=utf-8
"""
LoNote Book & Bookshelf 書籍與書櫃模組

伺服器對資料的操控全都要透過此層；而 IM 往上看時，也只需對此層負責。

===================================

類別說明：

    Book      = 可以使用各種不同的底層資料－－只要他們具有 IM 的 API。
                由 Bookshelf 協助初始化
    Bookshelf = 顧名思義是用來收藏一堆書的地方！
"""

import glob
import os
import os.path
import xml.etree.ElementTree as ET
import sys
import inspect
import collections

# 增加搜尋路徑
lolib_dir = os.path.dirname(inspect.stack()[0][1])
extra_path = os.path.join(lolib_dir, 'whoosh')
sys.path.insert(0, extra_path)

import whoosh.index as WI
import whoosh.analysis as WA
import whoosh.fields as WF
import whoosh.qparser as WQ


from . import im
import lonote.civa.parse
import lonote.civa.putil

class BookError(Exception): pass # 基本錯誤
class BookshelfError(Exception): pass # 基本錯誤
class MtimeNotMatch(BookError): pass # mtime 與 pubdate 對不上時拋出

class Book:
    '''伺服器對書的操作介面'''

    support_class = [im.IM]
    # 關於搜尋的設定
    INDEX_DIR = 'bookindex'

    # 將所有字母分成個別的詞
    # analyzer = whoosh.analysis.RegexTokenizer(r'\b|\B', gaps = True)

    # 將中文字依單一字分詞，其他英文字依照一般 word 分詞
    analyzer = WA.RegexTokenizer( '([\u2E80-\u9FFF])|(\w+(\.?\w+)*)')\
            | WA.StopFilter( minsize = 0)\
            | WA.LowercaseFilter()

    # 將所有字先標準分詞，再分成 Ngram，需時極長不實用
    # analyzer = whoosh.analysis.StandardAnalyzer() | whoosh.analysis.NgramFilter(minsize=2, maxsize=4)

    # 將中英文分開，中文字用 Ngram 分詞，英文採用標準（空白）分詞
    # 有奇怪的 bug ：在 StandardAnalysis Token >= 3 時，
    # 從第二個 Token 字句開始都會變成最後一個
    # analyzer = StandardAnalyzer() | TeeFilter(
    #         SubstitutionFilter( '^[\u2E80-\u9FFF]+', ''),
    #         SubstitutionFilter( '^[^\u2E80-\u9FFF]+', '') | NgramFilter( minsize = 2, maxsize = 4)
    #         ) | StopFilter()

    schema = WF.Schema(
            buuid = WF.ID(stored = True),
            uuid = WF.ID(unique = True, stored = True),
            title = WF.TEXT(analyzer = analyzer, stored = True),
            content = WF.TEXT(analyzer = analyzer, stored = True),
            mtime = WF.DATETIME(stored = True),
            ctime = WF.DATETIME(stored = True)
            )

    def __init__(self, fpath):
        '''載入指定將的書籍資料'''
        try:
            cls = next(cls for cls in Book.support_class
                    if cls.is_this_type(fpath))
        except StopIteration:
            info = 'file type not match'
            raise BookError(info)
        cls_obj = cls(fpath)
        self.__im = cls_obj

        self.__index_init()

    def __index_init(self):
        '''關於搜尋的初始化'''
        buuid = self.book_uuid()
        if WI.exists_in(Book.INDEX_DIR, indexname = buuid):
            self.__ix = WI.open_dir(
                    Book.INDEX_DIR, indexname = buuid)
        else:
            self.index_recreate()

    def index_recreate(self):
        '''重建 index'''
        buuid = self.book_uuid()
        self.__ix = WI.create_in(dirname = Book.INDEX_DIR,
                schema = Book.schema,
                indexname = buuid)
        # 遍歷內部所有資料
        ebook = ET.fromstring(self.book_xml())
        writer = self.__ix.writer()
        for enote in ebook.iter('note'):
            uuid = enote.get('uuid')
            content = self.content(uuid)
            txt = lonote.civa.parse.html2txt(content) # 純文字化
            writer.update_document(
                  buuid = buuid,
                  uuid = uuid,
                  title = self.title(uuid),
                  content = txt,
                  mtime = self.mtime(uuid),
                  ctime = self.ctime(uuid))
        writer.commit()

    def __index_update(self, uuid):
        '''更新指定 uuid 的 index'''
        buuid = self.book_uuid()
        content = self.content(uuid)
        txt = lonote.civa.parse.html2txt(content) # 純文字化
        if uuid is None:
            uuid = buuid
        writer = self.__ix.writer()
        writer.update_document(
              buuid = buuid,
              uuid = uuid,
              title = self.title(uuid),
              content = txt,
              mtime = self.mtime(uuid),
              ctime = self.ctime(uuid))
        writer.commit()

    def __index_delete(self, uuid):
        '''刪掉指定 uuid 的索引'''
        writer = self.__ix.writer()
        writer.delete_by_term('uuid', uuid)
        writer.commit()

    # 純讀取功能

    def book_uuid(self):
        '''筆記簿 uuid'''
        return self.__im.book_uuid()

    def book_xml(self):
        '''筆記的xml'''
        return self.__im.book_xml()

    def history_xml(self, uuid):
        '''某筆記頁的 history xml'''
        return self.__im.history_xml(uuid = uuid)

    def record_list(self, uuid):
        return self.__im.record_list(uuid = uuid)

    def resource_xml(self, uuid):
        '''某筆記頁的 resource xml'''
        return self.__im.resource_xml(uuid = uuid)

    def resource_list(self, uuid):
        return self.__im.resource_list(uuid = uuid)

    @property
    def fpath(self):
        '''回傳當初初始化用的fpath'''
        return self.__im.fpath

    @property
    def time_format(self):
        '''回傳xml所用的標準時間格式'''
        return self.__im.time_format

    def mtime(self, uuid):
        '''回傳筆記修改時間'''
        return self.__im.mtime(uuid)

    def ctime(self, uuid):
        '''回傳筆記建立時間'''
        return self.__im.ctime(uuid)

    def ftime(self, uuid, name):
        '''回傳歷史記錄凍結時間'''
        return self.__im.ftime(uuid, name)

    def position(self, uuid):
        '''回傳當前note位置，為(parent_uuid, index)'''
        return self.__im.position(uuid)

    def uuid_before(self, uuid):
        return self.__im.uuid_before(uuid)

    def uuid_next(self, uuid):
        return self.__im.uuid_next(uuid)

    def uuid_first(self, uuid):
        return self.__im.uuid_first(uuid)

    def uuid_last(self, uuid):
        return self.__im.uuid_last(uuid)

    # 讀取兼寫入功能

    def title(self, uuid = None, title = None):
        '''筆記名稱'''
        if title is None:
            return self.__im.title(uuid = uuid)
        else:
            ans = self.__im.title_update(uuid = uuid, title = title)
            self.__index_update(uuid)
            return ans

    def content(self, uuid, content = None, basetime = None):
        '''筆記內容

            content  = 要存入的文字，有設值表示寫入
            basetime = datatime 格式的物件，表示要存入的文字，是基於哪個
                       時間版本的內容而撰寫的。
                       如果有提供，則要求 basetime 等於指定筆記的 mtime，
                       以避免多人共同編輯時，互相覆寫彼此的修改版本。
                       沒有提供則無視。
            '''
        # def ftimes(uuid):
        #     xml = self.__im.history_xml(uuid = uuid)
        #     ehistory = ET.fromstring(xml)
        #     ftimes = [DT.datetime.strftime(e.text, self.time_format) for e
        #             in ehistory.find('./record/frozen_time')]
        #     return ftimes

        if content is None:
            return self.__im.content(uuid = uuid)
        else:
            if basetime and basetime != self.__im.mtime(uuid):
                info = 'Content update after load!'
                raise MtimeNotMatch(info)
            self.__im.content_update(uuid = uuid, content = content)
            self.__index_update(uuid)
            mtime = self.__im.mtime(uuid)
            return mtime

    def resource(self, uuid, name, res_type = None, data = None):
        '''附屬資源。如需更新附屬資源，需同時指定最後兩個參數'''
        if data is None:
            return self.__im.resource(uuid = uuid, name = name)
        else:
            return self.__im.resource_update(
                    uuid = uuid,
                    name = name,
                    res_type = res_type,
                    data = data)

    def record(self, uuid, *, name = None, comment = None, content = None):
        '''筆記記錄歷史資料

            name 設值表示讀取
            comment 設值表示寫入

            （以上兩者必需擇一。content只在寫入時起作用，也可以不給）

            如果設值操作回傳 name，若是讀取操作回傳內容
            '''
        if name is not None:
            return self.__im.record(uuid = uuid, name = name)
        elif comment is not None:
            name = self.__im.record_create(uuid = uuid,
                    comment = comment,
                    content = content)
            return name
        else:
            info = 'One of "name" & "comment" must be set!'
            raise BookError(info)

    def extra(self, key, obj = None):
        '''更新或讀取指定的extra

            當讀取時指定物件不存在，則回傳 None
            '''
        if obj is None:
            return self.__im.extra(key)
        else:
            return self.__im.extra_update(key, obj)

    # 更新與新建功能

    def position_update(self, uuid, parent_uuid, order):
        '''變更uuid的所在位置'''
        return self.__im.position_update(
                uuid = uuid, parent_uuid = parent_uuid, order = order)

    def note_create(self, title, parent_uuid, order = None):
        '''建立一頁新筆記'''
        return self.__im.note_create(
                title = title, parent_uuid = parent_uuid, order = order)

    # 刪除功能

    def note_delete(self, uuid, use_trashcan = True):
        '''刪除一個筆記'''
        if use_trashcan:
            ans = self.__im.note_delete(uuid = uuid)
        else:
            ans = self.__im.note_delete(uuid = uuid, trashcan_uuid = None)
        self.__index_delete(uuid)
        return ans

    def record_delete(self, uuid, name):
        '''刪除一筆歷史記錄'''
        return self.__im.record_delete(uuid = uuid, name = name)

    def resource_delete(self, uuid, name):
        '''刪除一份附加資源'''
        return self.__im.resource_delete(uuid = uuid, name = name)

    def extra_delete(self, key):
        '''刪掉extra'''
        return self.__im.extra_delete(key)

    # 補助功能

    def flush(self, mode = None):
        '''壓制書籍'''
        if mode is None:
            return self.__im.flush()
        else:
            return self.__im.flush(mode)

    def book_export(self, uuid, new_fpath):
        '''匯出'''
        return self.__im.im_export(uuid, new_fpath)

    def book_import(self, parent_uuid, in_fpath):
        '''匯入'''
        return self.__im.im_import(parent_uuid, in_fpath)

    # 查詢功能

    def search(self, key):
        '''輸入關鍵字串來搜索這本筆記'''
        ans = []
        with self.__ix.searcher() as searcher:
            # 產生查詢字串
            query = WQ.MultifieldParser(['title', 'content'], self.__ix.schema).parse(key)
            # 搜尋！
            results = searcher.search(query, limit = 10)
            for hit in results:
                path = '/site/{}/{}/'.format(hit['buuid'], hit['uuid'])
                try:
                    puuid, index = self.position(hit['uuid'])
                    ptitle = self.title(puuid)
                except im.IMNotExist:
                    puuid = ''
                    ptitle = '✖'
                ans.append({'title': hit['title'],
                    'uuid': hit['title'],
                    'buuid': hit['buuid'],
                    'path': path,
                    'puuid': puuid,
                    'ptitle': ptitle,
                    'btitle': self.title(),
                    'score': hit.score,
                    'fragment': hit.highlights("content")
                    })
            return ans

class Bookshelf():
    loadfile = 'needload.xml'

    def __init__(self, books_dir):
        self.books = collections.OrderedDict()
        self.books_dir = books_dir
        self.lf = os.path.join(self.books_dir, Bookshelf.loadfile)

        self.loadbooks()

    def load_status(self):
        '''回傳一個記錄載入狀況的字典

            return ==> {'load': [], 'indir': []}

            list 內全為不含路徑的檔名。

            load 代表目前設定為載入的筆記
            indir 代表目前放在筆記資料夾中，可以進行設定的筆記
            '''
        elf = self.elf()
        load = [efile.get('fname') for efile in elf]

        fname2buuid = {os.path.basename(book.fpath): buuid
                for buuid, book in self.books.items()}

        match = os.path.join(self.books_dir, '*.lobook')
        indir = [os.path.basename(fpath) for fpath in glob.glob(match)]
        return {'load': load, 'indir': indir, 'fname2buuid': fname2buuid}

    def elf(self):
        '''取得e_loadfile'''
        if os.path.isfile(self.lf):
            return ET.parse(self.lf).getroot()
        else: # 建立
            self.lf_init(default_all = True)
            return self.elf()

    def lf_init(self, default_all, *fnames):
        '''初始化 lf，如果有提供fnames則使用fnames初始化

            fname 不存在時：
            default_all = True 表示全部讀入
                          False 表示全部不讀入
            '''
        needload_files = [fname for fname in fnames]
        # 沒有指定 fnames 時，直接將所有能載入的檔案全部載入
        if not needload_files:
            if default_all:
                match = os.path.join(self.books_dir, '*.lobook')
                needload_files = [fpath for fpath in glob.glob(match)]
            else: pass

        # 產生完整的needload
        root = ET.Element('needload')
        for fpath in needload_files:
            fname = os.path.basename(fpath)
            ET.SubElement(root, 'file' , fname = fname)
        self.__elf_update(root)

    def __elf_update(self, root):
        '''把更新好的 elf 寫回檔案'''
        xml = ET.tostring(root, encoding = "unicode")
        with open(self.lf, mode = 'w', encoding = 'utf-8') as f:
            f.write(xml)

    def loadbooks(self):
        '''載入self.books_dir下所有的筆記本'''
        self.books.clear() # 先卸載原本的資料……
        elf = self.elf()
        for efile in elf.findall('./file'):
            fname = efile.get('fname')
            try:
                self.addbook(fname)
            # 可能因為某些原因（如重複、格式不正確等）而載入失敗，無需理會
            except BookshelfError: pass

    # def unloadbook(self, fname):
    #     '''卸載 fname 的書

    #         fname 可為筆記檔案名或 bookuuid 名
    #     '''
    #     book = self.books.get(fname)
    #     buuid = None
    #     if book:
    #         fname = book.fpath
    #         buuid = book.book_uuid()
    #     else:
    #         for buuid in self.books:
    #             book = self.books[buuid]
    #             if book.fpath == fname:
    #                 buuid = book.book_uuid()
    #                 break
    #     # 確實找到了，踢掉
    #     if buuid and fname:
    #         elf = self.elf()
    #         efile = elf.find('./file[@fname="{}"]'.format(fname))
    #         elf.remove(efile)
    #         self.__elf_update(elf)
    #         self.books.pop(buuid) # 從當前筆記簿資料庫中踢掉

    def create_book(self, fname):
        '''初始化一本新書。fname不可被佔用，否則拋出錯誤。'''
        # 計算路徑
        fpath = os.path.join(self.books_dir, fname)
        if os.path.isfile(fpath):
            info = '"{}" be occupied'.format(fpath)
            raise BookshelfError(info)
        else:
            return self.addbook(fname)

    def addbook(self, fname, file_obj = None):
        '''掛載名為 fname 的書，所有掛載書籍的動作都是透過這邊進行

            fname     = 為筆記檔案名
            file_obj  = 為一個可 read() 的檔案物件，
                        若為 None 表示讀取現有的檔案或新建檔案，
                        fpath　存在不存在都可以；
                        若非 None，fpath 不可被佔用，否則拋出錯誤

        '''
        def remove_unused_elf(fname):
            '''在elf中刪掉指定fname的項目'''
            elf = self.elf()
            efile = elf.find('./file[@fname="{}"]'.format(fname))
            if efile is not None:
                elf.remove(efile)
                self.__elf_update(elf)

        # 計算路徑
        fpath = os.path.join(self.books_dir, fname)
        # 將檔案寫入
        if file_obj:
            if os.path.isfile(fpath):
                info = '"{}" be occupied'.format(fpath)
                raise BookshelfError(info)
            else:
                with open(fpath, mode = "wb") as f:
                    f.write(file_obj.read())

        try:
            b = Book(fpath)
        except BookError:
            if file_obj: # 如果本回的資料是新寫入的，將這份不正確的資料移除
                os.remove(fpath)
            # 清除無用的 elf
            remove_unused_elf(fname)
            info = 'file not accept!'
            raise BookshelfError(info)

        # 如果原本就存在一筆有著相同 uuid 的資料，則後讀入的項目放棄載入
        if b.book_uuid() not in self.books:
            # 加入
            self.books[b.book_uuid()] = b
            elf = self.elf()
            if elf.find('./file[@fname="{}"]'.format(fname)) is None:
                ET.SubElement(elf, 'file', fname = fname)
                self.__elf_update(elf)
            return b
        else:
            # 此時要將它從elf中移除
            remove_unused_elf(fname)
            info = 'Duplicated bookuuid'.format(fpath)
            raise BookshelfError(info)

    def removebook(self, fname):
        '''刪除一本書'''
        fname = os.path.basename(fname)
        load_status = self.load_status()
        fname2buuid = load_status['fname2buuid']
        buuid = fname2buuid.get(fname)
        fpath = os.path.join(self.books_dir, fname)
        if buuid:
            book = self.findbook(buuid) # 找到
            # 從書架中踢掉
            self.books.pop(buuid)
            elf = self.elf()
            efile = elf.find('./file[@fname="{}"]'.format(fname))
            if efile is not None:
                elf.remove(efile)
                self.__elf_update(elf)
            # 先壓成一個檔案
            book.flush()
            # 只留下bak檔案
            bak_fpath = lonote.civa.putil.chext(fpath, 'bak')
            if os.path.isfile(bak_fpath):
                os.remove(bak_fpath)
            os.rename(fpath, bak_fpath)
        else:
            # 當書本未被讀入，嘗試讀取再移除
            try:
                self.addbook(fname)
            except: pass
            self.removebook(fname)


    def findbook(self, bookuuid):
        '''透過bookuuid尋找book'''
        book = self.books.get(bookuuid)
        if book is not None:
            return book
        else:
            raise BookshelfError('book not found!')

    def ebooks(self):
        '''回傳 ebooks'''
        return [ET.fromstring(b.book_xml()) for b in self.books.values()]

    def ehistory(self, bookuuid, uuid):
        '''回傳 ehistory'''
        xml = self.findbook(bookuuid).history_xml(uuid)
        return ET.fromstring(xml)

    def search(self, key):
        '''輸入關鍵字串，進行搜尋'''
        ans = []
        for book in self.books.values():
            ans.extend(book.search(key))

        ans = sorted(ans, key = lambda x: x['score'], reverse = True)
        return ans

    # def erecord(self, bookuuid, uuid, name):
    #     '''回傳 erecord'''
    #     ehist = self.ehistory(bookuuid, uuid)
    #     e = ehist.find('./record[@name="{}"]'.format(name))
    #     if e is None:
    #         info = 'No record name = "{}".'.format(name)
    #         raise BookshelfError('book not found!')
    #     return e
